/*
 * Permission to use, copy, modify, and/or distribute this software for
 * any purpose with or without fee is hereby granted.
 *
 * THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
 * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
 * FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
 * DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
 * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

/**
 * Common 'Trash' operations for the OS's Recycle Bin.
 *
 * Supports POSIX (XDG Specification), macOS, and Windows.
 *
 * Authors: Mio
 * Date: March 06, 2024
 * Homepage: https://codeberg.org/supercell/mlib
 * License: 0BSD
 * Standards: The FreeDesktop.org Trash Specification 1.0
 * Version: 0.4.0
 *
 * History:
 *      0.4.0 add support for macOS
 *      0.3.0 fix XDG naming convention bug
 *      0.2.0 added support for Windows
 *      0.1.0 is the initial version
 *
 * Macros:
 *   DREF = <a href="https://dlang.org/phobos/$1.html#$2">$2</a>
 *   LREF = <a href="#$1">$1</a>
 */
module mlib.trash;

import core.stdc.errno;

import std.file;
import std.path;
import std.process : environment;
import std.stdio;

/*
 * Permanetely delete all trashed records.
 *
 * This currently throws an Exception as it's not yet implemented.
 */
// void emptyTrash()
// {
//     throw new Exception(__PRETTY_FUNCTION__ ~ " not implemented");
// }

/*
 * Restore one (or all: "") trashed records
 *
 * Params:
 *  pathInTrash = The unique filename in the trash directory to
 *                restore.  By not providing an argument (or by
 *                passing `""`) this will restore _all_ files.
 *
 * Note: This currently throws an Exception as it's not yet
 *       implemented.
 */
// void restoreTrash(string pathInTrash = "")
// {
//     throw new Exception(__PRETTY_FUNCTION__ ~ " not implemented");
// }

/*
 * List all the files and directories currently inside the trash.
 *
 * Returns: A list of strings containing every filename in the trash.
 *
 * Note: This currently throws an Exception as it's not yet
 *       implemented.
 */
// string[] listTrash()
// {
//     throw new Exception(__PRETTY_FUNCTION__ ~ " not implemented");
// }


/**
 * Trash the file or directory at *path*.
 *
 * Params:
 *  path = The path to move to the trash.
 *
 * Throws:
 *  - $(DREF std_file, FileException) if the file cannot be trashed.
 */
void trash(string path)
{
   scope string pathInTrash;
   trash(path, pathInTrash);
}

///
unittest
{
   import std.stdio : File;
   import std.exception : assertNotThrown;

   // Create a file with some basic text
   auto file = File("hello.txt", "w+");
   file.writeln("hello, world!");
   file.close();

   assertNotThrown!Exception(trash("hello.txt"));
}

/**
 * Trash the file or directory at *path*, and sets *pathInTrash* to the
 * path at which the file can be found within the trash.
 *
 * Params:
 *  path = The path to move to the trash.
 *  pathInTrash = The path at which the newly trashed item can be found.
 *
 * Bugs: The *pathInTrash* parameter isn't supported on macOS or Windows.
 *
 * Throws:
 *  - $(DREF std_file, FileException) if the file cannot be trashed.
 */
void trash(string path, out string pathInTrash)
{
   version (OSX) {
      _macos_trash(path);
   } else version (Posix) {
      _posix_trash(path, pathInTrash);
   } else version (Windows) {
      _windows_trash(path);
   } else {
      throw new Exception(__PRETTY_FUNCTION__ ~ " is not supported on your OS");
   }
}

/**
 * Erase the file from the operating system.
 *
 * This skips the "trashing" operation and unlinks the file from the
 * system and recovers the space.  Files which have been erased are
 * not recoverable.
 *
 * Throws:
 *  - $(DREF std_file, FileException) if the file cannot be removed.
 */
void erase(string path)
{
   // Really just a convenience function.
   remove(path);
}


private:

/*
 * System specific implementation of the above functions.
 */

version(Posix) {

   import core.sys.posix.sys.stat;
   import std.conv : to;
   import std.string : toStringz;

   void _posix_trash(string path, out string pathInTrash) {
      if (false == exists(path)) {
         throw new FileException(path, ENOENT);
      }

      /*  "When trashing a file or directory, the implementation SHOULD check
       *   whether the user has the necessary permissions to delete it, before
       *   starting the trashing operation itself". */
      const attrs = getAttributes(path);
      if (false == ((S_IRUSR & attrs) && (S_IWUSR & attrs))) {
         throw new FileException(path, EACCES);
      }

      const pathDev = _posix_getDevice(path);
      const trashDev = _posix_getDevice(environment["HOME"]);


      // $topdir
      string topdir;
      // $trash
      string trash;

      /* w.r.t. homeTrash:
       *  "Files that the user trashes from the same file system (device/partition) SHOULD
       *   be stored here ... If this directory is needed for a trashing operation but does
       *   not exist, the implementation SHOULD automatically create it, without warnings
       *   or delays. */
      if (pathDev == trashDev) {
         topdir = _xdg_datahome();
         trash = buildPath(topdir, "Trash");
      } else {
         /*  "The implementation MAY also support trashing files from the rest of the
          *   system (including other partitions, shared network resources, and removable
          *   devices) into the "home trash" directory."
          *
          * I can only really test the partitions and removable devices, but I don't
          * have my desktop setup with multiple partitions.  Will check with removable
          * devices, but want to see same file system usage work first. */
         throw new Exception("The device for the Trash directory and the device for the path are different.");
      }

      const basename = baseName(path);
      const filename = stripExtension(basename);
      const ext = extension(basename);

      // $trash/files
      string filesDir = buildPath(trash, "files");
      if (false == exists(filesDir)) {
         mkdirRecurse(filesDir);
      }

      // $trash/info
      string infoDir = buildPath(trash, "info");
      if (false == exists(infoDir)) {
         mkdirRecurse(infoDir);
      }

      /*  "The names in [$trash/files and $trash/info] are to be determined by the
       *   implementation; the only limitation is that they must be unique within the
       *   directory. Even if a file with the same name and location gets trashed many times,
       *   each subsequent trashing must not overwrite a previous copy." */
      size_t counter = 0;
      string filesFilename = basename;
      string infoFilename = filesFilename ~ ".trashinfo";
      while (exists(buildPath(filesDir, filesFilename)) || exists(buildPath(infoDir, infoFilename))) {
         counter += 1;
         filesFilename = basename ~ "_" ~ to!string(counter) ~ ext;
         infoFilename = filesFilename ~ ".trashinfo";
      }

      {
         /* "When trashing a file or directory, the implementation MUST create the
          * corresponding file in $trash/info first." */
         auto infoFile = File(buildPath(infoDir, infoFilename), "w");
         infoFile.write(getInfo(path, topdir));
      }

      {
         string filesPath = buildPath(filesDir, filesFilename);
         rename(path, filesPath);
         pathInTrash = filesPath;
      }

      /* TODO: Directory size cache */
   }

   ulong _posix_getDevice(string path) {
      stat_t statbuf;
      lstat(toStringz(path), &statbuf);

      return statbuf.st_dev;
   }

   string _xdg_datahome() {
      if ("XDG_DATA_HOME" in environment) {
         return environment["XDG_DATA_HOME"];
      } else {
         return buildPath(environment["HOME"], ".local", "share");
      }
   }


   string getInfo(string src, string topdir) {
      import std.uri : encode;
      import std.datetime.systime : Clock;

      if (false == topdir.isParentOf(src)) {
         src = src.absolutePath;
      } else {
         src = relativePath(src, topdir);
      }

      string info = "[Trash Info]\n";
      info ~= "Path=" ~ encode(src) ~ "\n";


      /*
       * Prior to D 2.099.0, the toISOExtString method didn't
       * have a precision argument, which means it includes
       * fractional seconds by default.  So to accommodate
       * for earlier versions, just trim it off.
       */
      static if (__VERSION__ < 2099L) {
         import std.string : split;

         string dateTime = Clock.currTime.toISOExtString().split(".")[0];

         info ~= "DeletionDate=" ~ dateTime ~ "\n";
      } else {
         info ~= "DeletionDate=" ~ Clock.currTime.toISOExtString(0) ~ "\n";
      }


      return info;
   }


   bool isParentOf(string parent, string path) {
      import std.string : startsWith;

      path = path.absolutePath;
      parent = parent.absolutePath;

      return startsWith(path, parent);
   }
} // End of version(Posix)

/*
 * Disclaimer:
 *
 * I don't use Windows. As such, this may not be the _best_ way
 * to send a file to the recycle bin. In theory it shouldn't
 * break (given Windows' tendency for backwards support), but
 * if there is an error, you'll either have to let me know
 * or send a patch yourself.
 */
version(Windows) {
   import core.sys.windows.windows;

   import std.utf : toUTF16z;

   // There doesn't seem to be a way to determine the path of a
   // file in the Recycle Bin.
   void _windows_trash(string path) {
      // If the path is not absolute, then it won't be recycled.
      string absPath = absolutePath(path);

      SHFILEOPSTRUCT fileOp = SHFILEOPSTRUCTW(null, FO_DELETE);

      /*
      * NOTE:
      * While toUTF16z appends a null character to the input string,
      * SHFILEOPSTRUCT treats pFrom (and pTo) as a list of strings
      * separated by a single '\0'.  To specify the end of the list,
      * the string must end with double null terminator.
      *
      * See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-shfileopstructa#remarks
      */
      fileOp.pFrom = toUTF16z(absPath ~ '\0');
      fileOp.pTo = null;
      fileOp.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT;
      fileOp.fAnyOperationsAborted = FALSE;
      fileOp.lpszProgressTitle = null;

      if (0 != SHFileOperation(&fileOp)) {
         throw new FileException(path, "File could not be deleted");
      }
   }
} // End of version(Windows)

/*
 * Another disclaimer!
 *
 * I also don't partiularly use macOS, though I did manage
 * to purchase a third-hand macbook recently so I can test
 * my programs. Hopefully, it should work for a couple
 * more years.
 */
version(OSX) {
   import core.attribute : selector;

   // For the macOS version we need to use the functions provided by
   // the Foundation framework as this will ask for permission to
   // access the trash directory (this would not work otherwise).
   //
   // Unforunately, this isn't just a simple function that one can
   // call.

   extern(Objective-C)
   extern class NSObject
   {
      void release() @selector("release");
   }

   extern(Objective-C)
   extern class NSError : NSObject
   {
   }

   extern(Objective-C)
   extern class NSString : NSObject
   {
      static NSString stringWith(const char*) @selector("stringWithUTF8String:");
      NSString stringByExpandingTilde() @selector("stringByExpandingTilde");
   }

   extern(Objective-C)
   extern class NSURL : NSObject
   {
      static NSURL fileURLWithPath(NSString path) @selector("fileURLWithPath:");
   }

   extern(Objective-C)
   extern class NSFileManager : NSObject
   {
      static NSFileManager defaultManager() @selector("defaultManager");
      bool trashItemAtURL(NSURL url, NSURL resultingItemURL, NSError error) @selector("trashItemAtURL:resultingItemURL:error:");
   }

   void _macos_trash(string path)
   {
      // We need to expand the tilde first, in D.
      string expandedPath = expandTilde(path);
      NSString nsPath = NSString.stringWith(expandedPath.ptr);
      scope(exit) nsPath.release();

      NSURL baseURL = NSURL.fileURLWithPath(nsPath);
      scope(exit) baseURL.release();

      NSFileManager manager = NSFileManager.defaultManager();
      scope(exit) manager.release();

      // While it's possible to use the resultingItemURL argument,
      // it doesn't seem to work correctly in D.
      // Changing the NSURL to NSURL* as the type works, but creates
      // some cruft after the path as there is (presumably) no
      // terminating zero.
      manager.trashItemAtURL(baseURL, null, null);
   }
}
